Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303) - #316
Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303)#316aram356 wants to merge 116 commits into
Conversation
Design docs (spec + implementation plan + adoption guide) for GitHub Actions that deploy EdgeZero apps, superseding the Fastly-only monolith from #303. Architecture: - build-cli compiles the CLI package the *application* provides (a crate in the app's own workspace), from the app checkout, isolated CARGO_TARGET_DIR + --locked, self-describing tar (cli-meta.json). - deploy-core: adapter-independent shared engine scripts sourced by wrappers; provider creds/flags only via provider-env (deploy-step-scoped), provider-env-clear, deploy-flags, deploy-args. - deploy-fastly: minimal wrapper; optional stage: true. - Fastly staging lifecycle (parity with trusted-server-actions): deploy-fastly stage mode + healthcheck-fastly + rollback-fastly, scaffolded into the CLI's Fastly adapter and exposed via the app CLI; fastly-version output. Cross-cutting: Git root vs Cargo workspace root for monorepo caching; no Python (actionlint/zizmor pinned binaries); third-party actions on readable tags; explicit Fastly build-in-deploy credential caveat. Plan includes a porting map from the #303 reference scripts. Based off main; supersedes #303.
provider-env is no longer listed among the engine's globally-passed parameters. It is bound only to the deploy step's own env: and parsed only there; setup/build steps receive only non-secret parameters plus provider-env-clear. Mirrors spec §5.2/§10 so the plan no longer reintroduces the secret-blob leak.
…te target - healthcheck-fastly / rollback-fastly now pass --service-id <id> (and step- scoped FASTLY_API_TOKEN) in their app-CLI invocations; without it the CLI can't resolve staging IPs or activate/deactivate versions. - Make provider CLI install an explicit wrapper responsibility: deploy-fastly installs the pinned Fastly CLI onto PATH; the engine assumes it is present and never learns provider tools. healthcheck/rollback need no Fastly CLI (Fastly API only). - target is wrapper-provided concrete (Fastly -> wasm32-wasip1); the engine no longer maps adapter -> target, keeping it provider-neutral. - Qualify the follow-up list: additional staging/health/rollback lifecycles are 'beyond Fastly' (Fastly's is in scope).
… guide creds Gaps found in self-review + review: - §13 error handling: add rows for staged-deploy failure, missing fastly-version, unhealthy-after-retries, rollback failure. - Pin healthcheck-fastly exit semantics: exits non-zero on unhealthy so callers can gate rollback on if: failure() (the composing example relied on this implicitly). - §5.4.3: deploy-fastly stage command now shows --service-id (matches §5.4.1). - §15 testing + §17 acceptance: cover the staging lifecycle (were absent). - §15.3 / plan smoke test: fake the app CLI + Fastly API/curl for healthcheck/rollback (they call the API, not the fastly CLI), not fake fastly binaries. - Adoption guide §6.3: healthcheck/rollback steps now pass fastly-api-token + fastly-service-id (required by the CLI --service-id path).
- build-cli: action.yml + build-cli.sh (resolve app cli-package via cargo metadata --locked, isolated CARGO_TARGET_DIR build, cli-meta.json, tar upload). - deploy-core shared scripts: common, validate-inputs (provider-neutral allowlist + JSON→NUL parsing), install-rust (wrapper-provided target), download-cli (extract tar, read cli-meta.json, PATH-scope), resolve-project (Git root vs Cargo workspace root, cache key), cleanup, write-summary. Wrappers (deploy-fastly, healthcheck/rollback), run-cli, CI, and tests follow. All scripts shellcheck-clean; validate-inputs functionally tested.
Port install-fastly.sh (official release + SHA-256 checksum, action-owned PATH dir) and versions.json (Fastly 15.1.0) into the deploy-fastly wrapper. The wrapper action.yml and the shared run-cli.sh follow once the CLI staging contract is finalized.
…back wrappers - deploy-core/run-cli.sh: provider-neutral CLI runner; typed deploy-flags before --, caller passthrough after --; build-mode clears wrapper-named aliases. - deploy-fastly/action.yml: full orchestration (validate -> download+extract CLI -> resolve -> cache -> install rust + Fastly CLI -> optional build -> deploy), credential scoping via step-level env:, stage input -> --stage, captures fastly-version from the CLI's version=<N> line. - healthcheck-fastly / rollback-fastly: thin wrappers over <cli> healthcheck / rollback (Fastly API); healthcheck exits non-zero on unhealthy while still emitting healthy/status-code outputs. All action.yml parse; deploy-core scripts shellcheck-clean.
Apply Bash best-practices structure: wrap logic in main() with explicit local parameters and single-responsibility helpers; route the progress line to stderr; portable NUL-array collection (no bash 4.3 namerefs); a small named assertion harness (assert_succeeds/assert_fails/assert_equals) in the test runner. Kept coreutils short flags for macOS/BSD portability. All shellcheck-clean; 10/10 contract tests pass.
…st-toolchain - Apply the main()/helper structure and Bash best-practices across all engine scripts (validate-inputs, resolve-project, download-cli, install-fastly, cleanup, write-summary); route diagnostics to stderr; local scoping throughout. - Replace the custom deploy-core install-rust.sh with the maintained actions-rust-lang/setup-rust-toolchain@v1 (readable tag) in deploy-fastly, feeding the resolved toolchain + wasm32-wasip1 target; cache: false so our exact-key target/ cache stays authoritative. build-cli keeps rustup for dynamic (app-resolved) toolchain install. - Add .github/workflows/deploy-action.yml: no Python — actionlint from a pinned release binary, zizmor via cargo install (no pip), shellcheck, Bash contract tests, check-action-pins.sh (flags floating @main/@master refs), docs validation, and a build-cli -> deploy-fastly composite smoke test. - Add check-action-pins.sh; all third-party actions pinned to readable tags.
…thcheck, rollback) Add the CLI capability the deploy actions drive (spec §5.4): - args.rs: --service-id / --stage on DeployArgs; new HealthcheckArgs, RollbackArgs; Healthcheck/Rollback Command variants (+ arg-parse tests). - edgezero-adapter-fastly/cli.rs: deploy_staged (compute update --autoclone + service-version stage), emit_active_version, healthcheck (staging-IP resolution via Fastly API + curl), rollback (activate previous / deactivate staged); token piped via curl --config stdin so it never hits argv (+ 30 unit tests). - adapter registry + edgezero-cli adapter/lib/main dispatch wiring; other adapters return a clear 'unsupported' error, keeping WASM builds unaffected. - downstream CLI template: Healthcheck/Rollback arms + #[command(version)]. - Version output contract: a parseable 'version=<N>' line on stdout for deploy and staged deploy; 'rolled-back-to=<N>' / 'healthy=' / 'status-code=' for the lifecycle commands. All gated behind fastly/cli features. (Implemented by subagent; tests/clippy/fmt verified.)
… into feature/edgezero-deploy-actions
… smoke fixture - cli.rs tests: suffix numeric literals (default_numeric_fallback) and rename single-char closure params (min_ident_chars); bind+assert the ignored result (let_underscore_must_use). These fire under --all-targets, which the earlier clippy run omitted. Fastly tests: 100 pass; workspace clippy: 0 errors. - deploy-action.yml: scope actionlint to this workflow (no-arg actionlint tripped on pre-existing SC2086 in other repo workflows). - Extract the inline 'Create fixture app' block into deploy-core/tests/make-smoke-fixture.sh (shellcheck-linted) and add an empty [workspace] table so the fixture is standalone (fixes 'believes it's in a workspace').
- Set the git exec bit (100755) on run-cli.sh, deploy-fastly/common.sh, and install-fastly.sh (rewritten via editor, lost +x) so the composite actions can invoke them directly (fixes 'Permission denied' exit 126 in the smoke test). - Keep the readable @v1 tag on setup-rust-toolchain (design principle #9) and add an inline 'zizmor: ignore[unpinned-uses]' with justification, instead of an opaque SHA pin. - Give the smoke fixture a minimal fastly.toml so the CLI's Fastly deploy path reaches the fake fastly binary; assert the deploy reached 'fastly compute'.
- ShellCheck: exclude SC1091 (can't follow the dynamic $SCRIPT_DIR/common.sh source from repo root — an info finding, not a defect). zizmor now passes via the inline unpinned-uses ignore. - Smoke fixture: the real Fastly CLI (installed by install-fastly) shadowed the fake and errored on a missing package. Replace it with an edgezero.toml Fastly deploy-command override (the proven #303 approach) that records the passthrough argv; assert the typed --service-id (dummy-service) threaded through.
… cmd sites - cleanup.sh remove_if_present used '[[ -n && -d ]] && rm', which returns 1 when the dir is absent; called as a bare statement under set -e it exited non-zero, failing the deploy-fastly Cleanup step (with if: always()) even though the deploy succeeded. Use if/fi so it always returns 0. - Same footgun fixed in resolve-project.sh (lockfile hash — a real correctness bug for lockfile-less apps with cache:false) and check-action-pins.sh. - Relax the smoke assertion to marker-file existence (robust regardless of how the CLI threads passthrough args into an overridden manifest command).
…se 9) User-facing VitePress guide for the layered deploy actions: three-layer model, runner support, same-repo/separate-repo/monorepo checkout examples, build-cli and deploy-fastly input/output tables, typed-credential and trusted-ref guidance, the Fastly staging lifecycle (stage -> healthcheck -> rollback), build-mode/cache behavior, and job hardening. Wired into the VitePress sidebar under Reference. prettier + eslint + vitepress build pass locally.
HIGH 1. Fail closed on invalid lifecycle values. 'stage' must be exactly true|false (validate-inputs) and 'deploy-to' exactly production|staging (healthcheck / rollback wrappers). A typo previously fell through to PRODUCTION, so it could activate a previous production version. 2. Rollback used wrong Fastly API semantics: POST -> PUT, and staging rollback now uses PUT /version/<v>/deactivate/staging (was a plain /deactivate). Verified against Fastly's version API reference + the 2024-08 staging change. 3. curl-config injection: tokens/service-ids were interpolated into a 'curl --config -' document unescaped, so a quote/newline could terminate a value and inject options (another URL/proxy). Added curl_quote escaping plus validate_service_id / validate_version / validate_domain. The token still travels via the config file (never argv). 4. Implement the specified provider-env boundary. The wrapper no longer exports FASTLY_* directly; it passes typed values as data and run-cli.sh CLEARS every provider alias (FASTLY_TOKEN/ENDPOINT/API_URL/...) before exporting only the declared, typed credentials. Inherited aliases can no longer reach a deploy. 5. Staged deploy selected the wrong manifest: it bypassed manifest commands and searched fastly.toml from the cwd, ignoring EDGEZERO_MANIFEST — unsafe in monorepos. It now resolves and threads the configured manifest path. MEDIUM 6. A successful deploy could emit an empty fastly-version (errors were demoted to warnings), breaking deploy->healthcheck->rollback threading. Version is now parsed from the deploy output (canonical version=<N>, then Fastly's native phrasing), API only as fallback, Err if both fail; the action also fails if no version is emitted. 7. Lifecycle inputs are now required in the CLI: --service-id/--version for healthcheck and rollback, --domain for healthcheck; the token is required where it is actually used. 8. Test coverage: Bash contract tests 10 -> 19 (stage validation, artifact-name traversal, provider-env boundary), and the composite smoke now asserts version threading AND that an inherited FASTLY_ENDPOINT is cleared before deploy. 9. artifact-name is validated (no separators/traversal/leading dot) and the tarball name is fixed, so caller input is never a path component. Verified: cargo fmt/clippy(-D warnings)/test --workspace --all-targets, feature + spin-wasm checks, shellcheck, actionlint, 19/19 bash tests, prettier + docs build.
… into feature/edgezero-deploy-actions
The composite smoke test only covered a production deploy. The staging
lifecycle — stage, healthcheck, rollback — had no end-to-end coverage, which
is exactly where the review found real defects (--comment forwarded to a
command that doesn't support it, a plural staging_ips misread, POST instead of
PUT). Those are argv/verb bugs, so the test has to assert argv and verbs.
- lifecycle-smoke job: builds the app-owned fixture CLI, installs fake
`fastly`/`curl` that mirror the real contracts (singular `staging_ip`,
`--config -` on stdin), then drives stage -> healthcheck -> rollback through
the real wrappers and asserts:
* `compute update` carries --autoclone/--version=active/--non-interactive
and never --comment;
* the comment is applied via `service-version update` BEFORE staging;
* the probe is rerouted to the staging IP via --connect-to;
* an unhealthy probe FAILS healthcheck-fastly (the rollback gate);
* staging rollback PUTs /deactivate/staging, production PUTs
/version/41/activate, and rolled-back-to threads out.
- test.yml: run `cargo test -p edgezero-adapter-fastly --all-targets --features
cli`. The workspace gate never enabled the `cli` feature, so 115 adapter
dispatch tests were compiled by nothing but the clippy job.
- spec §9.1: document that compute-deploy-only flags are no-ops under --stage.
The lifecycle job's inline run blocks had grown into the largest logic in the workflow, unreadable and unlinted. Each assertion is now a named script under deploy-core/tests/ that documents the defect it regression-tests, and the YAML is a list of steps again.
…_<NAME> Security / correctness (High): - cleanup.sh removed $EDGEZERO_FASTLY_HOME, a variable nothing in the action ever set — so its value could only ever be inherited, making an `rm -rf` of the checkout (or anything on a self-hosted runner) reachable from job env. Dropped it, and confined every removal to real paths beneath RUNNER_TEMP, resolving symlinks before comparing. - run-cli.sh now scrubs its private env before exec'ing the app CLI. The typed token arrived twice — as a step variable and inside the provider-env JSON — and both stayed exported, so the CLI and every subprocess it spawned (including a manifest command) inherited the raw token under names we never promised. - Production deploy never threaded --manifest-path, so in a monorepo it fell back to "closest fastly.toml" and could deploy the wrong app. It is now threaded on both paths and stripped from the Fastly argv (compute deploy has no such flag). - A manifest-command deploy (`deploy = "fastly compute deploy"`) never received --non-interactive and could block on a TTY prompt in CI. The wrapper now supplies it as an action-owned passthrough arg; the built-in path dedupes it. Correctness (Medium): - Version parsing is anchored end-to-end. `version=15.2.0` used to parse as 15 and thread a version that was never deployed into healthcheck and rollback. - healthcheck/rollback validate their required inputs. GitHub does not enforce `required: true`, so an empty service-id or version silently reached the probe. - The toolchain search stops at the app's Git root, not github.workspace — in the separate-repo layout the deployer's .tool-versions was choosing the app's Rust. All paths canonicalized: a symlinked TMPDIR made the boundary never match. - Wrapper logs are mktemp/0600 and removed by an EXIT trap; the three aliases that were declared but never blanked (FASTLY_DEBUG_MODE/CONFIG_FILE/HOME) are blanked on every step, including third-party `uses:` steps. Env-var convention: Every action-owned variable is now EDGEZERO__<SECTION>__<NAME> — `__` between sections, `_` within. This is what makes the credential boundary a SINGLE rule (unset EDGEZERO__*) instead of a hand-maintained list that a later variable could silently escape. EDGEZERO_MANIFEST (single underscore) stays outside: it is the CLI's public contract, and the one variable we deliberately pass through. Also: build-cli -> build-app-cli (it builds the APP's CLI, never EdgeZero's own). Tests: lifecycle-smoke now drives stage -> healthcheck -> rollback through the REAL wrappers with the version threaded from the deploy output (no hard-coded 42), which required install-fastly.sh to become idempotent. Contract suite 29 -> 49, covering cleanup confinement, the env scrub, the action-owned passthrough, anchored parsing, private logs, and the toolchain boundary — the last of which caught the canonicalization bug above.
…app-cli.sh
The actions compile and run the CLI package the APPLICATION provides — never
EdgeZero's own. Half the names didn't say so, and "cli-artifact" / "cli-bin" /
"EDGEZERO__CLI__BIN" read as if they might be EdgeZero's CLI. That ambiguity is
exactly the thing this design exists to rule out, so it is swept from every layer:
- env vars: EDGEZERO__APP__CLI__{BIN,VERSION,ARTIFACT_DIR},
EDGEZERO__INPUT__APP_CLI_{PACKAGE,BIN,ARTIFACT}
- inputs: app-cli-package, app-cli-bin, app-cli-artifact
- outputs: app-cli-version, app-cli-package, app-cli-bin, app-cli-artifact
- scripts: download-app-cli.sh, run-app-cli.sh (build-app-cli.sh already renamed)
- artifact: app-cli-meta.json, with app-cli-{bin,version,package} keys
- docs: guide, spec, adoption guide, and plan all updated
The contract test for the artifact metadata caught the one place the rename would
have broken the wiring (the download step's outputs), which is what it is for.
…kes on ubuntu-latest The smoke fixture had no `build` subcommand, so build-mode: always — the only mode that seeds the credential-free cache — could not run, leaving cache population and restore untested. Give the fixture a `build` subcommand (dispatching to run_build) and a manifest `build` command (fake-build.sh) that credential-free-populates target/ and is IDEMPOTENT: it only stamps a fresh marker when one was not restored from the cache, which is how a restore hit is told apart from a rebuild. A .gitignore keeps target/ from dirtying the source guard. New cache-smoke job: deploy with build-mode: always + cache: true (miss → seed build → save), delete target/, then deploy again (restore HIT). The capture/assert helpers prove the marker came back from the cache, not disk. Validated locally: the fixture compiles with the new subcommand, the build populates the marker, and a second build leaves it untouched. fake-deploy.sh also gains a FAKE_LOSE_VERSION path (mutates the service but emits no version line) for an upcoming lost-version recovery smoke; it is inert until a job sets that variable. Run the deploy smokes on ubuntu-latest (was ubuntu-24.04); docs updated to match.
…cond deploy stays clean The cache smoke deploys twice; the first deploy writes env-seen.txt and deploy-argv.txt into fixture-app, which the second deploy's committed-source guard saw as a dirty tree (single-deploy smokes never hit this). Add both to the fixture .gitignore alongside target/, so ls-files --exclude-standard excludes them and the guard passes. Verified locally.
…its version, then recover Exercises the recovery flow the guide documents — artifact download, active-version recovery, and rollback — together, end to end. Induction: with FAKE_LOSE_VERSION set, fake-deploy activates the service (v7) but emits no version line AND trips a fake API-break sentinel. The sentinel is created DURING the deploy, so the rollback-target capture that ran BEFORE it still saw a working API (previous=40); the CLI's post-deploy version fallback then hits the broken API (fake curl returns 500 for active-version while the sentinel exists) and deploy-fastly fails with mutation-attempted=true. Both hooks are inert for every other smoke (no sentinel file, FAKE_LOSE_VERSION unset). Recovery (recovery-smoke job): assert the deploy failed but signalled a possible mutation; download the CLI artifact; clear the sentinel and run active-version to recover the now-live version (7); then rollback-fastly to the captured previous version (40), asserting rolled-back-to=40 and that the fake service is actually back at 40. Validated locally: the fixture compiles, FAKE_LOSE_VERSION suppresses the version line, and recovery-active-version.sh extracts the artifact and reads the recovered version. The full failed-deploy/rollback interaction runs on GitHub.
…thread previous-version in recovery; fix spec ordering Pin gate (check-action-pins.sh): a text regex could not see every YAML spelling of a `uses` key, and a unicode-escaped key (`"uses":`) slipped `@main` past it; the gate also scanned only the deploy files, not the whole repo. Rewrite it to parse YAML STRUCTURALLY with yq and scan every workflow and action.yml repo-wide, so no quoted/escaped/tagged/multiline/flow form can hide a floating ref anywhere. The contract test now proves each of those forms is rejected (skips when yq is absent locally; CI's static-checks has it). zizmor stays scoped to the deploy surface — it cannot tell a tag from a branch, so the tag-vs-branch decision is the pin script's, and a repo-wide zizmor run would surface unrelated pre-existing findings. Spec §9 updated. Cache restore gating (deploy-fastly): restore ran whenever cache: true, even under build-mode: never where the seed build and save are skipped — a no-op the docs call out. Gate restore on effective-build-mode == always too, so cache: true + never does nothing at all. composite-smoke documents that negative path; cache-smoke covers the positive one. Recovery smoke: it hardcoded rollback-to: "40" and ignored the failed deploy's previous-version output. Thread `steps.deploy.outputs['previous-version']` into rollback-to (as the guide documents) and assert the failed deploy still exposes previous-version=40 — proving the output propagates through the failure path, not just mutation-attempted. Spec cache ordering: the normative step list saved the cache AFTER the credential-bearing deploy; the implementation saves the credential-free build BEFORE it. Reorder §6 so save sits with the build step (16), and note restore is gated on build-mode: always.
…; regression-test the negative cache case Pin gate correctness (check-action-pins.sh): - FAIL CLOSED on a parse/tool failure. The yq call ran in a `2>/dev/null` process substitution whose exit status was never checked, so malformed YAML (with @main) passed. Capture yq's output and status; a file the gate cannot parse is now rejected. - Match ONLY genuine action references — workflow job-level and step `uses`, and composite `runs.steps[].uses` — instead of any map with a `uses` key. The old query rejected unrelated fields like `jobs.<job>.env.uses`. - Require mikefarah yq v4 specifically. The old version check matched the `mikefarah` URL that yq v3 also prints, so it accepted v3 (whose query syntax differs). Contract test now wraps every fixture as a real workflow and adds the env.uses (accepted) and malformed-YAML (rejected, fail-closed) cases. Pin gate coverage (deploy-action.yml paths): the repository-wide gate ran only when a deploy-path file changed, so a floating ref added to test.yml or a brand-new action directory would never start it. Trigger on `.github/actions/**` and `.github/workflows/**` in both PR and push filters. Cache negative case is now REGRESSION-TESTED, not just commented: cache-smoke deletes target/ and does a build-mode: never + cache: true deploy, then asserts nothing was restored (restore is gated on build-mode: always). Reintroducing the old restore condition fails here. Dropped the misleading cache: true from composite-smoke (a no-op that asserted nothing). Spec/plan ordering: both used build-mode to gate cache restore before "resolving" it. Resolve build-mode with the other resolve-project outputs (step 12), so the cache and build steps just consume it.
…ll-semver tags Actionlint scope: the static-checks job linted only two workflows, so a problem in test.yml/format.yml/etc. went unchecked despite the spec's static-validation requirement. Run actionlint with no file args (every .github/workflows/*.yml) and `-shellcheck='shellcheck -S warning'` — repo-wide lint passes at that floor. Spec §15.1 updated to "every workflow file". yq pinned: the pin gate depended on the runner-provided yq, contrary to the "validation binaries are pinned and checksum-verified" requirement. Add scripts/install-yq.sh (mirrors install-actionlint.sh: downloads the pinned mikefarah yq release binary and verifies its SHA-256 from the release `checksums`, extracting the SHA-256 column via `checksums_hashes_order`), a YQ_VERSION env, and an install step before the pin gate and the bash suite. Spec §15.1 notes yq is a pinned, checksum-verified binary. Version-tag regex: `@v1.2.3-rc.1+build.5` (a valid semver with BOTH a prerelease and build-metadata suffix) was rejected because the suffix group matched only once. Allow an optional prerelease AND an optional build-metadata suffix. Test asserts the combined form is accepted (branches still rejected).
…cation; shellcheck install-yq; doc .yaml
RUNNER_TEMP isolation (security principle 5): actionlint, yq, and zizmor were
installed to /usr/local/bin (or Cargo home) and invoked by bare name, which
neither honors the RUNNER_TEMP rule nor proves the checksum-verified binary is
the one that runs. Install all three under $RUNNER_TEMP/tools/bin (yq/actionlint
via INSTALL_DIR, zizmor via `cargo install --root`), prepend that dir to PATH so
the verified copies win, verify each reports its pinned version, and invoke
actionlint and zizmor by ABSOLUTE path. The pin gate and bash suite call yq by
name, now resolved to the RUNNER_TEMP copy first on PATH.
CI ShellCheck: the script list ended at install-actionlint.sh and omitted the
new install-yq.sh; added it.
Docs: actionlint discovers *.{yml,yaml}, not only *.yml — spec and the workflow
comment corrected; the spec also notes the validation binaries are pinned and
installed under RUNNER_TEMP.
…n't silently no-op actionlint's `-shellcheck` integration quietly disables itself and exits 0 when shellcheck is not on PATH, so `run:`-block defects would pass unchecked on any runner that does not preinstall shellcheck (the hosted runner's preinstalled copy was masking this). Move the Install ShellCheck step ahead of the "Actionlint (all workflows)" step so the integration is always active.
Bump the deploy-actions surface to current, maintained majors, aligning
its pin style with the rest of the repo (major tags, not full-patch pins):
- actions/checkout v4 -> v7
- actions/download-artifact v4.3.0 -> v8
- actions/upload-artifact v4.6.2 -> v7
- actions/cache/{restore,save} v4.3.0 -> v6
- actions-rust-lang/setup-rust-toolchain v1.17.0 -> v1
The v4+ artifact backend keeps upload (v7) and download (v8) cross-
compatible, so the build/deploy handoff is unaffected. The pin gate,
zizmor ref-pin policy, and the artifact/cache/checkout smoke jobs all
validate the new tags. Reword the setup-rust-toolchain pin comment to
say 'major version tag' now that it tracks v1 rather than an exact patch.
prk-Jr
left a comment
There was a problem hiding this comment.
PR Review
Summary
15.5k lines across 73 files, and the layering thesis holds up: the app CLI really is the boundary, the wrappers really are thin, and adding a second provider really would be a new wrapper rather than an engine rewrite. Read every changed file; ran all five CI gates locally (all pass). The Bash contract suite, the golden public-surface test, and the credential-free cache ordering are better than most repos ship, and the code is unusually honest about its own residual risks.
The blocking findings share one root cause: build-app-cli establishes a real untrusted-build boundary, and deploy-fastly's seed build does not inherit it. Everything the build-app-cli docstrings warn about — a build.rs writing $GITHUB_PATH, persisting state a later privileged step consumes — applies to deploy-fastly's build-mode: always step, which runs in the same job that then receives the Fastly token. Two more findings (the .curlrc channel, the mutable major-tag pins) are the same shape: something an earlier step can leave behind that a token-bearing step later trusts.
😃 Praise
assert_safe_tarball(deploy-core/scripts/common.sh:158) identifies and fixes a genuinepipefail+ SIGPIPE fail-open where the symlink check would have been false precisely when a symlink was found. Documented in place. That class of bug is normally found in production, not review.- The fake is delivered through the real trust path.
make-fake-fastly-env.sh:247-267packages the fakefastlyas a tar.gz and repoints the checked-outversions.jsonwith a matching SHA-256, soinstall-fastly.shverifies and extracts it through its genuine download → checksum → extract path instead of adopting a planted binary — andrun.sh:757-788guards against that override ever being committed. The part that can't be faked gets its own path-filtered job against the real release. That split is rare and right. - The golden public-surface test (
run.sh:1631-1755) pins the exact input/output names, required flags, and defaults per action, which is why the docs findings below are docs bugs rather than implementation drift. EDGEZERO__as a one-rule credential boundary (run-app-cli.sh:144-176): a singlecompgen -eprefix scrub instead of a list that rots, withEDGEZERO_MANIFESTdeliberately outside it — and a docstring that states plainly what the boundary is not ("NOT a process-image boundary — on Linux this shell's original environment stays readable via/proc/<ppid>/environ"). Documented residual risk beats an overclaimed guarantee, here and atcli.rs:1999-2002.- Fail-closed version parsing throughout.
parse_canonical_version_linerejectingversion=15.2.0rather than reading15,last_version_afterrequiring a terminator, andresolve_active_versionrefusing to read a garbled or multi-active response as "no active version." Each of those is a wrong-version-into-rollback bug that isn't there.
Findings
Blocking
- 🔧 Untrusted seed build shares a job with the token —
deploy-core/scripts/run-app-cli.sh:194.buildmode runs the app's and every dependency'sbuild.rswithGITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUTlive;build-app-clistrips exactly these viaenv -u … execandrun_untrusted, and this path does not. Reaches theDeployandCapture rollback targetsteps'jq/tee/grepcalls. Gated onbuild-mode: always— which is also the only mode where the advertisedtarget/caching works. - 🔧 Pin gate accepts the mutable refs it claims to reject —
deploy-core/tests/check-action-pins.sh:48.(\.[0-9]+)*admits bare major tags; the branch head then moved every third-party ref onto them, includingactions-rust-lang/setup-rust-toolchain@v1inside the token-bearing job. Three separate comments (this file,zizmor.yml:11-13,run.sh:1758) assert immutability that isn't there. - 🔧
curlreads~/.curlrcinto the token-bearing config document —crates/edgezero-adapter-fastly/src/cli.rs:2200. No-q, so abuild.rsin the same job can plant aproxy =directive and receive theFastly-Keyheader. The env-var variant of this attack is closed by the re-exec; the filesystem variant is not. - 🔧
BASH_ENV/ENVblanked on 4 of 10 bash steps —deploy-fastly/action.yml:109and the same gap in all three other wrappers. The unprotected steps are the ones producingdeploy-flags-fileandprovider-env-clear-file, and the ones installing the two binaries the token steps execute.build-app-cligets this right on all four of its steps, so it's an evenness problem, not a missing idea.run.sh:443-479tests the invariant against a hardcoded step whitelist, which hides the gap. - 🔧
deploy.shtakes "last wins" where its sibling requires "exactly one" —deploy-fastly/scripts/deploy.sh:51. Not reachable through EdgeZero's own CLI (its canonical line is emitted last); reachable with a non-conforming app CLI, which is the exact producercapture-previous.sh:78-90was hardened against. - 🔧 Tool checksums fetched from the asset's own origin —
scripts/install-yq.sh:60,scripts/install-actionlint.sh:47-52;cargo install zizmorhas no pin at all.yqis the pin gate, andcheck-action-pins.sh:81-83reports success on an empty ref list — a substitutedyqmakes the gate green while parsing nothing.versions.json:6already demonstrates the in-repo-digest pattern. - 🔧 Production config push has no committed-source guard —
config-push-fastly/scripts/config-push.sh:65. NoResolve project, so noassert_committed_sourceandGITHUB_WORKSPACEas the confinement root — the boundaryresolve-project.sh:137-141explicitly rejects for the separate-repo layout. An uncommitted config can be pushed to the store the live service reads, with nosource-revisionto reconcile.
Non-blocking
- 🤔 Staging IP unvalidated; IPv6 misparses —
cli.rs:2088. Fails closed, but the outcome is an automatic rollback of a healthy staged deploy, reported as "unhealthy." Oneparse::<IpAddr>()fixes both this and the recursive-descent looseness infind_staging_ip. - 🤔 zizmor's hardcoded file list —
deploy-action.yml:106. Skipscodeql.yml(security-events: write) anddeploy-docs.yml(pages: write+id-token: write), and no tool audits compositeaction.ymlfor template injection. A new wrapper with${{ github.event.* }}in arun:block passes every gate here. - 🤔
docker://is wholly exempt from the pin gate —check-action-pins.sh:63-65.docker://ghcr.io/x/y:latestpasses a gate whose docstring promises to reject floating refs. Also, only.github/workflows(maxdepth 1) and.github/actions/**/action.y*mlare scanned, so a valid local action at e.g.tools/deploy/action.ymlis never parsed. - 🤔 The checksum comparison is never negatively tested —
install-fastly.sh:74. The smoke computes the expected SHA from the archive it just built (make-fake-fastly-env.sh:255), so inverting or deleting the comparison leaves every job green. Arun.shcase that corrupts the archive and asserts the mismatch message would close it. (Relatedly,fastly-installer-check.yml:3-4now contradictsmake-fake-fastly-env.sh:17-23about whether the real checksum path is exercised elsewhere.) - 🤔 Skipped tests are counted as passes —
run.sh:289,:1952,:2019callpass "…(skipped: non-Linux runner)", and:958/:1268/:1333/:1499dogit init -q 2>/dev/null || return 0, so agit initfailure silently deletes four whole suites with no diagnostic. On a macOS dev box the run is green with several suites never executed. A separateskip()counter reported apart fromPassed:would make that visible. - 🤔
test_recovery_version_parsecovers no production code —run.sh:2211-2262tests a string literal defined inside the test, and the shipped helperrecovery-active-version.sh:22-25implements a third, incompatible parse that rejects the emptyversion=the test asserts must succeed. Extracting one parse and having both the doc snippet and the helper use it would make the test mean something. - 🤔 Partial download poisons the tool root —
install-fastly.sh:71-74.curl --outputtruncates before the transfer completes, so a reset mid-download leaves a short archive; the documented idempotency then skips the refetch on retry and fails the checksum forever, reading as a supply-chain alarm rather than a network blip. Download to a scratch name andmvafter verification. - 🤔 Lifecycle log lives outside the workspace
cleanupdeletes —deploy-core/scripts/common.sh:218-222puts it inRUNNER_TEMP, so the in-process EXIT trap is the only thing that removes it — and no trap survives the SIGKILL after a cancellation grace period. Mode 600 doesn't help; every step in a job is the same uid.EDGEZERO__ACTION__WORKSPACEis already exported to those steps. - 🤔 Inline config written to a predictable path, with a trap that breaks on quotes —
config-push-fastly/scripts/config-push.sh:127-134.>follows symlinks and doesn't create exclusively, and$$is small and reusable; on a self-hosted runnerRUNNER_TEMPpersists across jobs. Separately, the immediate-expansion trap (trap "… '$inline_file'") fails to parse if the path contains a quote, silently losing the cleanup. Single-quote the trap andmktempthe file —cleanup_sensitive_tempsalready tolerates an empty path. - 🤔 Resolved Rust toolchain is never shape-checked —
resolve-project.sh:45-52/build-app-cli.sh:60-68. It reachesrustup toolchain install,cargo +…, a third-party action input, and the cache key. It's the only free-form repo-sourced string in the PR with no regex gate; a checked-inrust-toolchainof--profile completeis parsed as an option. Worst case is a confusing failure, and the guard is one line. - 🤔 Allowlist admits one unvalidated token per permitted flag —
validate-inputs.sh:76assumes every allowlisted flag takes a value. Not exploitable with today's single-entry allowlist (--comment), but the moment a boolean flag is added,--flag <anything>becomes an unchecked path into the provider argv, silently. - 🤔
local argv=("$(resolve_app_cli)" …)masks the:?guard —healthcheck-fastly/scripts/healthcheck.sh:74-84.local x=$(false)exits 0, so the diagnostic prints and execution continues withargv[0]="". Fails closed at 127, but it's the errexit-masking pattern the rest of the PR is careful to avoid, and this is the only lifecycle script omittingrequire_cmd "$cli_bin". - 🤔
cache: trueis a silent no-op under the documented default —resolve-project.sh:98-104mapsauto → neverfor Fastly, and both cache steps requirealways.validate-inputs.sh:103-106acceptscache: trueregardless with no warning, so a user who sets onlycache: truegets nothing and no signal. - 🤔
cache_keyomitsbuild-args—resolve-project.sh:218. Two invocations at the same revision with different--featuresshare one entry, and the first writer wins for the key's life. Cargo's fingerprinting degrades this to extra rebuilds rather than a wrong artifact, but it defeats the exact-key contract the surrounding comment establishes for workspace identity. - 🤔
provider-env-clear: ''degrades to[]instead of failing closed —build-app-cli/action.yml:24-37documents "the build fails closed"; an explicitly-empty input doesn't take the default, andprovider_env_clear_names '[]'validates cleanly with zero names. The static layer still covers all 25 shipped aliases, so only a caller's own alias leaks. - 🤔 Two smoke assertions are outcome-only —
deploy-action.yml:619-621,:670-672assertoutcome == "failure"under whole-compositecontinue-on-error, so they'd pass ifprepare-workspaceor the installer checksum failed instead.assert-stale-rollback-refused.sh:46already demonstrates the log-delta pattern that fixes it. - 🤔 Fake
fastlynever records the credential in scope, and always exits 0. The fakecurldoes recordPROBE-TOKEN=and gets a real assertion; the fakefastlydoesn't, so the staged-deploy and config-push jobs never verify which token arrived, andcurl_config_capture's non-zero-exit path (cli.rs:2223-2229) is unreachable in test. Alsoconfig-store-entry describealways returns the "absent" shape, so an update-an-existing-key push is untested. - 🤔 Assertions on source text rather than behavior —
run.sh:494-495greps an env key name in YAML under the description "cleanup removes the workspace root";:2122-2126pins the spelling of a loop, so rewriting it as an array fails with correct behavior;:917-927is a baregrep … || failwith no matchingpass, so success is invisible in the count. - 🔧 Docs:
--yesis mandatory without a TTY and isn't documented —config.rs:854errors,cli-reference.md:257only mentions prompting, and both the adoption guide (:232) and deploy guide (:742) invite users to runconfig pushdirectly. A copy-pasted CI invocation hard-fails. The action compensates silently (config-push.sh:148appends--yes --no-diff) and no input table says so. - 🔧 Docs:
cli-reference.mdhas no section forhealthcheck,rollback, oractive-version— the exact three commandsdeploy-github-actions.md:38-40requires a hand-written app CLI to expose. Their required flags and defaults appear nowhere; the env-var table (:418-424) also omitsFASTLY_API_TOKENandFASTLY_SERVICE_ID, without which every lifecycle command hard-fails. - ⛏
app-cli-bin's default is documented wrong in four tables —deploy-github-actions.md:306,:464,:484,:509say "artifact's name"; the real default is theapp-cli-binfield ofapp-cli-meta.json(download-app-cli.sh:69), i.e. the built binary name. The spec has it right. - ⛏ The separate-repo example won't run as pasted —
deploy-github-actions.md:96-126usestoken: ${{ steps.app-token.outputs.token }}with noid: app-tokenstep in the snippet, andref: ${{ inputs.ref }}with noon:block. The adoption guide states the assumption in prose; the snippet doesn't. Worth one line noting@<ref>must be replaced too, since no example in either guide is runnable verbatim. - ⛏
cli-reference.md:236/:298attribute the fullconfig push/config diffflag surfaces to the bundlededgezerobinary, whose subcommands are hidden trailing-var sinks that exit 2. The typed split is explained, but only after the flags are attributed. Also:315/:327claim exit 0 without--exit-code, whileconfig.rs:408returns 2 forUnsupportedregardless. - ♻️ Three spellings of one verb across the action surface —
stage:vsdeploy-to:vs--staging. See the inline note ondeploy-fastly/action.yml:42; these are the names downstream repos pin, so the window to change them closes at merge. - ♻️
deploy-fastly/scripts/common.shis a third diverged copy of the helper set and is never sourced byrun.sh; 5 of its 13 functions have no consumer anywhere, including the path-confinement helperis_under:81. No test would catch further drift. - 🌱 Zero-coverage production code with real logic:
write-summary.sh(runsif: always()in five jobs; its "never emits credentials" contract is unenforced and nothing readsGITHUB_STEP_SUMMARY),verify-installed-version.sh:18,install-fastly.sh:33 provider_bin_dir(its entire rationale — an app CLI legitimately namedfastly— can't fire because the fixture CLI isfixture-app-cli), andresolve-project.sh:164-169's symlink-escape guard (whose twin inconfig-push.shis tested). - 📝
is_healthy_statuscounts 3xx as healthy (cli.rs:1851). Defensible and documented, but for a gate that triggers an automatic rollback, a staged version answering301to an error page passes. Worth a thought about 2xx-only or making it configurable. - 📝
tee_streamswallows read errors (adapter.rs:191):Ok(0) | Err(_) => breakmeans one non-UTF-8 byte in a child's output silently truncates both the captured text and the operator-visible echo. Logging the error before breaking would make that diagnosable. - 📝
actions/checkoutis pinned at both@v6and@v7in the same repo.$GITHUB_ACTION_PATH/...is unquoted in everyrun:(harmless on hosted runners; word-splits under a workspace path with spaces).build-app-cli.sh:226re-declareslocal workspace_real, changing the variable's meaning mid-function.
📌 Out of Scope
- Service-scoped serialization for deploy/rollback.
ensure_rollback_from_is_activenarrows the clobber window andcli.rs:1999-2002says plainly that it can't close it. The fix is a concurrency group per service in the calling workflow, not in this PR — but it belongs in the guide's reconcile section, since a rollback that lands after a newer deploy is the failure this PR can't prevent alone. post:cleanup. Composite actions have none, so cancellation/SIGKILL can leave the chmod-600 lifecycle log behind. Harmless on hosted runners, a cross-job leak on self-hosted — andvalidate-inputs.sh:37-42doesn't exclude self-hosted. Tracking a documented "hosted runners only" constraint (or a reaper) is probably its own issue.- The production deploy path in
composite-smoke/handoff-deployruns a fake shelldeploycommand (make-smoke-fixture.sh:184-186), so no realfastly compute deploy/activate is ever exercised. The staged path does bypass manifest commands and test the real adapter, so this is a known and reasonable limit — worth naming in the spec's testing section rather than fixing here.
CI Status
Run locally against 93a9b90:
cargo fmt --all -- --check— PASScargo clippy --workspace --all-targets --all-features -- -D warnings— PASScargo test --workspace --all-targets— PASS (1238 passed, 0 failed)cargo check --workspace --all-targets --features "fastly cloudflare spin"— PASScargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin— PASS
GitHub checks are all green (23/23), including the full Deploy-actions smoke suite. No CI failures — every finding above is a design/hardening issue the gates don't cover.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Reviewed the Trusted Server/EdgeZero deployment, staging, healthcheck, rollback, security, and recovery paths. I found four actionable issues; details are inline. Current CI is green. Approving under the review rubric, while recommending that the P1 findings be addressed before merge.
Pin gate (keep major-version tags, correct the immutability claims): - Reword check-action-pins.sh header, zizmor.yml, and the run.sh suite so none of them claim a version tag is immutable — a major tag is publisher-repointable; the gate enforces a concrete, reviewable ref, not cryptographic immutability. Pin to a full SHA where that matters. - Reject floating docker:// refs (bare image / :latest); accept an @<algo>:<digest> or a version tag. Add contract cases. - Widen the default scan to action.yml anywhere in the repo (local actions), not just under .github/actions. Fastly adapter / CLI (Rust): - curl_config_capture and the health probe now lead with -q so curl never merges ~/.curlrc into a token-bearing --config document (a same-job build step could otherwise plant a proxy= directive and exfiltrate Fastly-Key). - Bound every Fastly API call with --connect-timeout/--max-time and surface curl's exit 28 as an explicit timeout error (rollback is time-sensitive). - Validate the resolved staging IP as an IpAddr before it reaches --connect-to, and bracket IPv6 literals so curl does not misparse them. - is_healthy_status is 2xx-only: a 3xx to an error page must not suppress an automatic rollback (the probe does not follow redirects). - Confine the adapter platform-manifest path under the manifest root: canonicalize and reject absolute/traversal/symlink escapes so a credential-bearing fastly build/deploy cannot run against out-of-repo source. Adds absolute/traversal/symlink regression tests. - Staging selector twin: upsert the full desired set BEFORE deleting stale entries, so a concurrently-linked staged version never sees a required selector transiently absent (fall-through to production) and a partial failure leaves a superset. Document the residual per-service concurrency limit (serialize with a per-service concurrency group). - Production healthcheck: when a token is available, require the requested version to be ACTIVE before and after the probe, so a concurrently activated newer version is not reported as a healthy `version`; without a token the check is documented service-level. - tee_stream logs a read error before breaking instead of silently truncating captured + echoed child output.
Untrusted-build isolation: - run-app-cli.sh build mode re-execs with GITHUB_ENV/PATH/OUTPUT/STATE/ STEP_SUMMARY (and BASH_ENV/ENV) stripped, so a build.rs in the seed build cannot append a shim to a channel the later token-bearing steps trust. Deploy mode is unchanged (trusted app CLI). Adds a build-isolation contract test. - BASH_ENV/ENV are now blanked on EVERY run: step across all five actions (they were even on only 4/10 deploy-fastly steps); the scrub test enumerates run steps from the YAML instead of a hardcoded whitelist, so a new unguarded step fails CI. Every run: invocation also quotes $GITHUB_ACTION_PATH. Version threading: - deploy.sh requires EXACTLY ONE canonical version= line (mirroring the rollback-target capture) rather than last-wins, so a non-conforming app CLI cannot thread a version that was never deployed into healthcheck/rollback. - recovery-active-version.sh captures the CLI's exit status (no errexit-masked abort) and requires one well-formed version= line. Tool installers: - install-yq.sh / install-actionlint.sh verify against SHA-256 digests PINNED IN THE REPO, not the release's own checksum file — a compromised origin can serve a matching bad checksum, and yq IS the pin gate. install-fastly.sh downloads to a scratch path and mv's into the cache only after the checksum verifies, so a partial download never poisons the idempotent cache. - The pin gate fails closed if a whole-repo scan parses ZERO refs (a broken/ swapped yq), rejects floating docker:// refs, and scans local action.yml anywhere in the repo. Immutability claims corrected (major tags are mutable). config-push committed-source guard: - config pushed from the checked-out tree now requires committed source (shared assert_committed_source, moved to common.sh); inline content is exempt. The inline temp file is mktemp'd (exclusive, unpredictable) instead of a predictable $$ path. Consolidation: delete the diverged deploy-fastly/scripts/common.sh (a stale subset) and point install-fastly.sh at the shared deploy-core copy, dropping its duplicated require_linux_x86_64. Docs: document that config push requires --yes without a TTY (the action adds it); add cli-reference sections for healthcheck/rollback/active-version + the FASTLY_API_TOKEN/FASTLY_SERVICE_ID env vars; correct the app-cli-bin default in four tables; make the separate-repo example runnable; fix config push/diff attribution and exit-code docs.
resolve-project.sh: - Shape-check the resolved Rust toolchain (channel/version token only) before it reaches rustup, cargo +<tc>, a third-party action input, and the cache key — a checked-in rust-toolchain of '--profile complete' no longer parses as an option. - Fold build-args into the cache key so two invocations at one revision with different --features do not share (and clobber) a target/ cache entry. - Drop the redeclared source_revision local; document RUNNER_OS/RUNNER_ARCH and build-args in the Reads table. validate-inputs.sh: - The deploy-arg allowlist now distinguishes value-taking flags (marked with a trailing '=', e.g. --comment=) from boolean flags, so adding a boolean to the allowlist can no longer let '--boolflag <anything>' smuggle an unchecked token into the provider argv. A boolean given a value is rejected. - Warn when cache: true is set without build-mode: always (a silent no-op today). Credential-boundary evenness: - build-app-cli.sh fails closed on an explicitly-blank provider-env-clear instead of degrading to [] (scrub nothing); renames the redeclared workspace_real to cargo_ws_real; documents three previously-undocumented env vars. - healthcheck.sh resolves the app CLI on its own line + require_cmd, so a failed ':?'-guarded resolve stops the step instead of running with an empty argv[0]. - The lifecycle log is minted inside the per-invocation action workspace (which cleanup removes wholesale) rather than RUNNER_TEMP, so it dies even when the EXIT trap cannot fire (SIGKILL after a cancellation grace period). Test fidelity: - A skip() counter reports non-Linux / missing-yq / failed-git-init cases apart from Passed, so a green run that silently skipped whole suites is visible. - A negative install-fastly checksum test (corrupt archive -> mismatch) closes a gap where inverting the comparison left every job green. - assert-config-push.sh sources common.sh instead of redefining fail() to log to stdout without >&2.
The deploy surface spelled the staging verb three ways: deploy-fastly's boolean 'stage', the lifecycle actions' 'deploy-to: production|staging', and the CLI's --staging. Downstream repos pin these input names, so the window to make them consistent closes at merge. Rename deploy-fastly's input 'stage: true|false' to 'deploy-to: production|staging' (default production), matching config-push/healthcheck/ rollback and the CLI. The wrapper derives --staging only for exactly 'staging', and validate-inputs.sh now rejects any deploy-to that is neither production nor staging (a typo can never silently reach production — the same fail-closed guarantee the boolean had). Updates the plumbing (EDGEZERO__DEPLOY__STAGE -> EDGEZERO__DEPLOY__TO), the smoke workflow, the golden public-surface test, the validate-inputs tests, and the guide + specs.
Reconcile the staging-lifecycle feature with main's config-gc / config-store refactor in the Fastly adapter. Took main's lead on the shared config-store plumbing (stricter fail-closed store-list scan, resolve_remote_config_store_id returning Option, redacted describe/stderr diagnostics, strict_stdout, FUTURE_FORMAT_READ_ERROR) and the whole config gc feature; kept the staging lifecycle and the review-fix hardening (curl -q + timeouts, 2xx-only health, staging-IP IpAddr validation + IPv6 bracketing, production version verification, write-before-delete selector mirror). Renamed the staging delete helper to delete_staging_config_store_entry to coexist with gc's delete_config_store_entry, and unioned both test suites. CLI template + app-demo gain the config gc command alongside the lifecycle commands; cli-reference merges the richer --dry-run text with the new --yes/no-TTY guidance.
curl_config_capture closed the child stdin with an explicit drop(stdin), which trips clippy::drop_non_drop on wasm32-wasip1 where std::process::ChildStdin is not Drop (the fastly cli wasm-clippy job builds this code). Hand the handle to a by-value write_config_to_curl_stdin helper so it drops at scope end instead — the same pattern main already uses for write_value_to_fastly_stdin. Verified with cargo clippy -p edgezero-adapter-fastly --target wasm32-wasip1 --features 'fastly cli' --all-targets -- -D warnings.
The strict exactly-one-version= parse broke the production smokes (composite, cache, handoff-deploy): a conforming deploy legitimately prints the version TWICE, because the app CLI tees the provider output (which carries a version= line) before emitting its own canonical version=<N>. Key on the DISTINCT set instead of the raw count: benign duplicates of the same version collapse to one value, while two DIFFERENT versions still fail closed rather than guessing which was deployed (a missing/malformed line also fails closed). Adds run.sh coverage for both the duplicate-accepted and conflicting-rejected cases.
…og cleanup, docs P1 — config-store list errors no longer leak values. read_config_store_entries's parse/schema-drift/malformed-entry errors embedded the raw stdout, which carries every item_value (possibly production secrets) into retained CI logs. Split the parse into a pure parse_config_store_entries and route every error through redact_describe_response (size + top-level shape only). Adds sentinel-secret regression tests for malformed JSON, schema drift, and a malformed entry. P1 — adoption guide no longer offers the unsupported stage: input. The migration table said deploy-fastly (stage: input); an unknown input is only a warning, so the production default stood. Now deploy-to: staging; fixed the plan's wording too. P2 — deploy.sh rejects a malformed version line even beside a valid one. It grepped only well-formed lines, so version=42 + version=43x passed. Now every ^version= line must be well-formed before the valid values are deduplicated; a malformed line fails closed. Adds run.sh coverage. P2 — documented the self-hosted runner floor: Actions Runner 2.327.1+ for the Node 24 actions (download-artifact@v8, cache@v6, upload-artifact@v7, checkout@v7), in the guide and the spec. P3 — the sensitive lifecycle log now lands in the per-invocation workspace. common.sh prefers EDGEZERO__ACTION__WORKSPACE, but the capture/deploy/healthcheck/ rollback/config-push steps never passed it, so logs stayed under RUNNER_TEMP where the workspace cleanup cannot reach them. Wired it into all five token steps. P3 — corrected the pin-policy spec prose: it claimed immutable/exact while both using and discouraging @v4. Now states the accepted policy — full SHA or a version-shaped tag INCLUDING a movable major tag; branches/floating refs rejected; not an immutability guarantee.
Summary
Layered, adapter-independent GitHub Actions for deploying an EdgeZero app to Fastly Compute — design + implementation, complete — superseding the Fastly-only monolith in #303. The EdgeZero CLI is the boundary: the actions compile the app's own CLI, scope credentials, and invoke it; they never reproduce provider build/deploy logic in YAML, so adding another provider later is a new thin wrapper, not an engine rewrite. Based off
main.Actions
build-app-cli— compiles the CLI package the application provides (a crate in the app's own workspace) from the app checkout, with an isolatedCARGO_TARGET_DIR+--locked; publishes a self-describing tar (app-cli-meta.json) so downstream steps need no re-pass. Credential-free by design.deploy-core— adapter-independent shared engine scripts sourced by the wrappers (not a standalone action). Provider credentials/flags flow only throughprovider-env(deploy-step-scoped),provider-env-clear,deploy-flags, anddeploy-args.deploy-fastly— minimal wrapper; installs the pinned, checksum-verified Fastly CLI;stage: trueproduces a staged draft; outputsfastly-version,previous-version(the rollback target captured pre-deploy),mutation-attempted, and the installed provider-CLI version.healthcheck-fastly/rollback-fastly— Fastly staging lifecycle (parity withstackpop/trusted-server-actions), driven by the app CLI over the Fastly API.config-push-fastly— pushes the app's typed config to a Fastly config store (the production key, or the isolated_stagingtwin).CLI
edgezero-adapter-fastlyand the scaffolded downstream template gain the lifecycle command surface the actions drive:build,deploy(--staging,--service-id),active-version,healthcheck,rollback, and typedconfig push/validate/diff.--stagingis one consistent verb across the lifecycle; the legacy--stageis rejected (never aliased) and cannot slip through--passthrough into a production deploy.Security & isolation
BASH_ENV/ENV, and the credential-free CLI build re-execs with the GitHub file-command channels stripped.target/caching is credential-free: the cache is seeded and saved from thebuild-mode: alwaysbuild before the token-bearing deploy, never after — so a build script cannot persist a secret into the cache. Withbuild-mode: neverit is a documented no-op.check-action-pins.sh) parses every workflow and action structurally with a pinned, checksum-verifiedyq(installed underRUNNER_TEMP), rejecting anyuses:on a mutable branch/floating ref while accepting released version tags or full SHAs.actionlint(all workflows, with its ShellCheck integration) andzizmorback it up.mutation-attemptedreconcile signal on the mutating actions, fail-closed input validation, a dirty-source guard, and committed-source-only deploys.End-to-end smoke coverage
The
Deploy actionsworkflow drives the real wrappers against a fakefastly/curlserved through the installer's genuine download + checksum + extract path:static-checks— actionlint, the structural pin gate, zizmor, ShellCheck, the Bash contract suite, and the docs build.composite-smoke— production deploy, the credential boundary, and rollback threading.handoff-build/handoff-deploy— cross-job artifact handoff by literal name.cache-smoke— cache populate + restore-hit, plus a negative (build-mode: never) no-op case.recovery-smoke— an induced lost-version deploy failure, recovered viaactive-version+rollback-fastlythreadingprevious-version.config-push-smoke— typed staging and production config push.lifecycle-smoke— staged deploy + healthcheck.Docs
docs/specs/edgezero-deploy-github-action.md— normative spec.docs/specs/edgezero-deploy-action-implementation-plan.md— plan (+ Add Fastly deploy action with config push #303 port map).docs/specs/edgezero-deploy-adoption-guide.md— adoption guide (any app repo).docs/guide/deploy-github-actions.md— practical how-to;docs/guide/cli-reference.md— CLI surface.Notes
Supersedes #303 (and the earlier stacked docs PR #315); #303's unrelated changes (KV timing logs, dep bumps) are not carried here. All CI is green: Run Tests, Run Format, CodeQL, Fastly installer check, and the Deploy-actions smoke suite.